Pro Entity Framework Core 2 for ASP.NET Core MVC 翻译

第 18 章 手工建模数据库

作者:Adam Freeman
翻译:陈广
日期:2019-5-16


在17章,我使用脚手架进程来为现有数据库自动创建数据模型。脚手架进程很方便,但是它提供的细粒度控制方式不多,使用起来可能会很尴尬。在本章中,我将向您展示如何手动建模数据库。这个过程需要更多的工作,但是生成一个更自然的数据模型,并且在数据库更改时更容易管理。表18-1为本章简述。

表 18-1:手工建模数据库简述

问题 回答
它们是什么? 手工建模在不使用脚手架的情况下为数据库创建数据模型
它们有何用途? 脚手架功能并不总是能够处理大型和复杂的数据库。
如何使用它们 Fluent API 用于在 context 类中配置数据模型。
是否有任何缺陷或限制? 如果仅对数据库的一部分进行建模,则必须确保排除的部分不存在依赖关系。
有没有其他选择? 唯一的选择是使用脚手架视图。

表 18-2:本章摘要

问题 解决方案 清单
手工建模数据库 创建 context 类并使用 Fluent API 配置数据模型 1-11,19-23
手工建关系 定义导航属性并使用特性或 Fluent API 语句配置关系 12-18

准备本章

本章使用第17章创建的 ExistingDB 项目,并依赖于在该章节中创建的数据库。如果您已直接跳转到本章,您需要按照第17章开头所述的步骤创建数据库,以便本章中的示例使用。

提示:您可以下载项目,其中包含创建数据库所需的 SQL 语句的文件,作为免费下载本书源代码的一部分,https://github.com/apress/pro-ef-core-2-for-asp.net-core-mvc

为确保数据库已经创建,并包含正确数据,使用dotnet run启动应用程序,并导航至 http://localhost:5000,将产生图18-1所示的结果。

图18-1 运行示例应用程序

创建手工数据模型

在开始手工建模现有数据库之前,您必须理解它的架构,并且知道数据库的哪一部分用于 ASP.NET Core MVC 应用程序。如果没有向您提供数据库及其设计的详细说明,那么运行第18章中描述的脚手架过程可能是一个有用的起点,即使您在手工构建模型时仅将脚手架数据模型用作参考。

创建 Context 和实体类

手动创建数据模型的起点是创建 context 类。我创建了一个 Models/Manual 文件夹,并在其中添加了一个名为 ManualContext.cs 的类文件,用于定义清单18-1所示的类。

清单 18-1:Models/Manual 文件夹下的 ManualContext.cs 文件的内容

using Microsoft.EntityFrameworkCore;

namespace ExistingDb.Models.Manual
{
    public class ManualContext : DbContext
    {
        public ManualContext(DbContextOptions<ManualContext> options)
            : base(options) { }
        public DbSet<Shoe> Shoes { get; set; }
    }
}

ManualContext类派生自DbContext,具有一个构造函数,该构造函数接收传递给基类构造函数的配置对象,并定义一个名为ShoesDbSet<T>属性,该属性提供对Shoe对象集合的访问。如果这看起来很熟悉,那是因为您可以通过遵循前面章节中使用的约定来对数据库进行建模。为了定义 context 使用的Shoe类,我在 Models/Manual 文件夹中添加了一个名为 Shoe.cs 的类文件,并添加了清单18-2所示的代码。

清单 18-2:Models/Manual 文件夹下的 Shoe.cs 文件的内容

namespace ExistingDb.Models.Manual
{
    public class Shoe
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
    }
}

尽管我还没有创建导航属性,但Shoe类包含Shoes表中某些列的属性。为了使新的 context 可供应用程序的其他部分使用,我创建了清单18-3所示的服务。

清单 18-3:ExistingDb 文件夹下的 Startup.cs 文件,创建服务

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Configuration;
using ExistingDb.Models.Scaffold;
using Microsoft.EntityFrameworkCore;
using ExistingDb.Models.Manual;

namespace ExistingDb
{
    public class Startup
    {
        public Startup(IConfiguration config) => Configuration = config;

        public IConfiguration Configuration { get; }

        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
            string conString = Configuration["ConnectionStrings:DefaultConnection"];
            services.AddDbContext<ScaffoldContext>(options =>
                options.UseSqlServer(conString));
            services.AddDbContext<ManualContext>(options =>
                options.UseSqlServer(conString));
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseDeveloperExceptionPage();
            app.UseStatusCodePages();
            app.UseStaticFiles();
            app.UseMvcWithDefaultRoute();
        }
    }
}

新上下文使用与脚手架进程创建的 context 相同的连接字符串,因为两者都连接到同一个数据库。

创建控制器和视图

为测试初始数据模型,我在 Controllers 文件夹中创建了一个名为 ManualController.cs 的类文件,添加清单18-4所示的代码。

清单 18-4:Controllers 文件夹下的 ManualController.cs 文件的内容

using ExistingDb.Models.Manual;
using Microsoft.AspNetCore.Mvc;

namespace ExistingDb.Controllers
{
    public class ManualController : Controller
    {
        private ManualContext context;
        public ManualController(ManualContext ctx) => context = ctx;
        public IActionResult Index() => View(context.Shoes);
    }
}

Index action 将 context 的Shoes属性返回的DbSet<Shoe>对象传递给默认视图。为了给该 action 提供视图,我创建了 Views/Manual 文件夹,并向其添加了一个名为 Index.cshtml 的视图,其内容如清单18-5所示。

清单 18-5:Views/Manual 文件夹下的 Index.cshtml 文件的内容

@using ExistingDb.Models.Manual
@model IEnumerable<Shoe>
@{
    ViewData["Title"] = "Manual Data Model";
    Layout = "_Layout";
}

<div class="container-fluid">
    <h4 class="bg-primary p-3 text-white">Zoom Shoes</h4>
    <table class="table table-striped table-sm">
        <tr><th>Id</th><th>Name</th><th>Price</th></tr>
        @foreach (Shoe s in Model)
        {
            <tr>
                <td>@s.Id</td>
                <td>@s.Name</td>
                <td>$@s.Price.ToString("F2")</td>
            </tr>
        }
    </table>
</div>

该视图显示一张表格,其中包含从 action 方法接收序列中的每个Shoe对象的IdNamePrice值。要测试手工数据模型,请使用dotnet run启动应用程序,然后导航到 http://localhost:5000/manual,这将产生如图18-2所示的结果。

图18-2 测试手工数据模型

理解基本数据模型约定

如上一节所示,如果您能够遵循 Entity Framework Core 所期望的约定,那么手动创建数据模型是很容易的。即使在一个简单的数据模型(如我在上一节中创建的模型)中,我也依靠几个约定从数据库中获取数据。我将在适当的时候解释其中的每一个,但我将集中讨论这些最基本的约定:

  • context 类中属性的名称与数据库表的名称相对应,因此 context 类中的Shoes属性对应于数据库中的Shoes表。(如果没有 context 类属性,并且通过Set<T>方法访问数据,则使用实体类的名称作为表名。)
  • 实体类中属性的名称与数据库表中的列的名称相对应,因此IdNamePrice属性将用于表示Shoes表中IdNamePrice列中的值。
  • 主键将由一个名为Id<Type>Id的属性表示,这样Shoe类的主键将是一个名为IdShoeId的属性

当迁移被用于从 context 和实体类创建数据库时,这些是你在前几章中已经习惯的约定。Entity Framework Core 在使用现有数据库时使用相同的约定,这意味着可以在不需要任何显式配置的情况下建立对象和数据库之间的映射。

重写数据模型约定

当数据库的设计与应用程序中需要的内容一致时,Entity Framework Core 约定非常适用。但这种情况很少发生,尤其是当您试图将现有数据库集成到项目中时。Entity Framework Core 提供了两种不同的方式来重写约定,以便您可以创建适合项目的 ASP.NET Core MVC 部分的数据模型,同时仍然提供对数据库中数据的访问:特性和 Fluent API。特性易于使用,但不提供对所有 Entity Framework Core 功能的访问。Fluent API 更复杂,但对如何将数据模型映射到数据库提供了更大的控制。我在后面的部分中介绍了这两种方法,并演示了如何使用它们来重写前面描述的三种基本约定。

使用特性重写数据模型约定

基础数据模型约定可使用表18-3描述的特性重写,这些特性应用于实体模型类。

表 18-3:用于重写基础数据模型约定的特性

名称 描述
Table 此特性指定数据库表,并重写 context 类中属性的名称。
Column 此特性指定为其应用的属性提供值的列。
Key 此特性用于标识将分配主键值的属性。

我在 Models/Manual 文件夹下添加了一个名为 Style.cs 的文件,并使用它定义清单18-6所示的类,演示了这些特性是如何工作的。

清单 18-6:Models/Manual 文件夹下的 Style.cs 文件的内容

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

namespace ExistingDb.Models.Manual
{
    [Table("Colors")]
    public class Style
    {
        [Key]
        [Column("Id")]
        public long UniqueIdent { get; set; }
        [Column("Name")]
        public string StyleName { get; set; }
        public string MainColor { get; set; }
        public string HighlightColor { get; set; }
    }
}

Table特性告诉 Entity Framework Core 此类的数据源是Colors表。我使用了Key特性指定UniqueIdent属性将用于主键值,同时使用Column特性确保Id列将被这些值作为源。我使用了Column特性告诉 Entity Framework Core StyleName属性的值将从Name列获取。

您只需要为所需的更改应用特性,这样我就可以依赖MainColorHighlightColor属性的约定,这些约定将从相同名称的列中分配值。为了完成这个示例,我向 context 类添加了一个属性,以使访问数据更加方便,如清单18-7所示。

清单 18-7:Models/Manual 文件夹下的 ManualContext.cs 文件,添加约定属性

using Microsoft.EntityFrameworkCore;

namespace ExistingDb.Models.Manual
{
    public class ManualContext : DbContext
    {
        public ManualContext(DbContextOptions<ManualContext> options)
            : base(options) { }
        public DbSet<Shoe> Shoes { get; set; }
        public DbSet<Style> ShoeStyles { get; set; }
    }
}

在介绍完 Fluent API 之后,我将在下一节使用这些特性访问数据。


在特性和 Fluent API 之间进行选择。

可以使用特性或 Fluent API 重写基本数据模型约定,因此您可以选择最自然的方法。一些开发人员更喜欢用特性注释类,因为它更符合 ASP.NET Core MVC 特性(如验证和授权)的工作方式。其他开发人员更喜欢在 context 类中描述数据模型,以便能够在一个地方看到和理解来自常规约定的所有更改。

有些高级功能只能通过 Fluent API 使用。如果您需要使用这些功能来建模数据库,那么您别无选择,只能使用Fluent API,即使您更喜欢使用特性。尽管如此,正如本章所演示的,您可以混合匹配特性以及 Fluent API 来创建数据模型,这意味着如果您喜欢,可以对支持它们的功能使用特性。如果是这样的话,请记住 Fluent API 语句优先于特性,如果使用 Fluent API 覆盖相同的约定,那么特性将被悄悄忽略。


使用 Fluent API 重写模型约定

Fluent API 用于以编程方式描述数据模型的部分来重写数据模型约定。特性适合于进行简单的更改,但最终您将不得不处理这样一种情况,即没有合适的特性,这需要高级功能,而这种功能只有 Fluent API 支持。

注意:本章,我使用 Fluent API 来建模现有数据库,但它也可以在代码先行应用程序中用于微调迁移,如本书第 3 部分所示。

为了展示一个与上一节使用特性的等价的示例,我在 Models/Manual 文件夹中添加了一个名为 ShoeWidth.cs 的文件,并使用它定义了清单18-8所示的类。

清单 18-8:Models/Manual 文件夹下的 ShoeWidth.cs 文件的内容

namespace ExistingDb.Models.Manual
{
    public class ShoeWidth
    {
        public long UniqueIdent { get; set; }
        public string WidthName { get; set; }
    }
}

我要用ShoeWidth类来表示Fittings表中的数据。该类不遵循 Entity Framework Core 约定:类名与数据库表名不匹配,我希望对IdName列使用UniqueIdentWidthName属性。

通过重写OnModelCreating方法在 context 类中应用 Fluent API,而不是修改实体类,如清单18-9所示。

清单 18-9:Models/Manual 文件夹下的 ManualContext.cs 文件,使用 Fluent API

using Microsoft.EntityFrameworkCore;

namespace ExistingDb.Models.Manual
{
    public class ManualContext : DbContext
    {
        public ManualContext(DbContextOptions<ManualContext> options)
            : base(options) { }
        public DbSet<Shoe> Shoes { get; set; }
        public DbSet<Style> ShoeStyles { get; set; }
        public DbSet<ShoeWidth> ShoeWidths { get; set; }
        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            modelBuilder.Entity<ShoeWidth>().ToTable("Fittings");
            modelBuilder.Entity<ShoeWidth>().HasKey(t => t.UniqueIdent);
            modelBuilder.Entity<ShoeWidth>()
                .Property(t => t.UniqueIdent)
                .HasColumnName("Id");

            modelBuilder.Entity<ShoeWidth>()
                .Property(t => t.WidthName)
                .HasColumnName("Name");
        }
    }
}

OnModelCreating方法接收一个ModelBuilder对象,给 Fluent API 使用。ModelBuilder类定义的最重要的方法是Entity<T>,它允许将实体类描述为 Entity Framework Core,并重写约定,否则将使用的约定。

Entity<T>方法返回一个EntityTypeBuilder<T>对象,它定义了一系列方法用于将数据模型描述为 Entity Framework Core。表 18-4 描述了清单 18-9 中使用的EntityTypeBuilder<T>方法。

提示:那些选择实体类属性的 Fluent API 方法,如表18-4描述的HasKeyProperty方法是重载的,以便将属性指定为字符串或使用 lambda 表达式。Lambda 表达式避免只在应用程序运行时造成的错误,因此这些是我在本书中使用的方法。

表 18-4:清单 18-9 中使用的EntityTypeBuilder<T>方法\

名称 描述
ToTable(table) 此方法用于指定实体类所对应的表,等效于Table特性。
HasKey(selector) 此方法用于指定实体类的关键属性,等效于Key特性。参数是选择关键属性的 lambda 表达式。
Property(selector) 此方法用于选择属性,以便让它描述得更为详细,如下面文字所述。

ToTableHasKey方法被单独使用来指定数据库表和ShoeWidth类的主键属性。Property方法用于选择要进一步配置的属性,并返回PropertyBuilder<T>对象,其中T是所选属性返回的类型。PropertyBuilder<T>类定义了许多用于提供对属性的细粒度控制的方法,我将在本章和接下来的章节中对这些方法进行描述。清单18-9中使用的PropertyBuilder<T>方法是HasColumnName方法,它用于选择数据库表列,该列将为所选属性提供值,并在表18-5中描述,以供快速参考。

**表 18-5:清单 18-9 中使用的PropertyBuilder<T>方法

名称 描述
HasColumnName(name) 此方法用于选择将为所选属性提供值的列,等效于使用Column特性。

使用自定义数据模型

不管您是使用特性还是 Fluent API,一旦您重写了这些约定来创建应用程序所需的数据模型,您就可以像通常一样使用 context 类和实体类。为了完成本节,我在Manual控制器的Index action 中添加了语句,该控制器使用ViewBagShoeStyleShoeWidth对象传递给默认视图,如清单18-10所示。

清单 18-10:Controllers 文件夹下的 ManualController.cs 文件,使用自定义模型

using ExistingDb.Models.Manual;
using Microsoft.AspNetCore.Mvc;

namespace ExistingDb.Controllers
{
    public class ManualController : Controller
    {
        private ManualContext context;
        public ManualController(ManualContext ctx) => context = ctx;
        public IActionResult Index()
        {
            ViewBag.Styles = context.ShoeStyles;
            ViewBag.Widths = context.ShoeWidths;
            return View(context.Shoes);
        }
    }
}

为向用户显示数据,我向 Views/Manual 文件夹下的 Index.cshtml 视图添加了如清单 18-11 所示的内容。

清单 18-11:Views/Manual 文件夹下的 Index.cshtml 文件,显示附加数据

@using ExistingDb.Models.Manual
@model IEnumerable<Shoe>
@{
    ViewData["Title"] = "Manual Data Model";
    Layout = "_Layout";
}

<div class="container-fluid">
    <h4 class="bg-primary p-3 text-white">Zoom Shoes</h4>
    <table class="table table-striped table-sm">
        <tr><th>Id</th><th>Name</th><th>Price</th></tr>
        @foreach (Shoe s in Model)
        {
            <tr>
                <td>@s.Id</td>
                <td>@s.Name</td>
                <td>$@s.Price.ToString("F2")</td>
            </tr>
        }
    </table>
    <div class="row">
        <div class="col">
            <h5 class="bg-primary p-2 text-white">Styles</h5>
            <table class="table table-striped table-sm">
                <tr>
                    <th>UniqueIdent</th>
                    <th>Style Name</th>
                    <th>Main Color</th>
                    <th>Highlight Color</th>
                </tr>
                @foreach (Style s in ViewBag.Styles)
                {
                    <tr>
                        <td>@s.UniqueIdent</td>
                        <td>@s.StyleName</td>
                        <td>@s.MainColor</td>
                        <td>@s.HighlightColor</td>
                    </tr>
                }
            </table>
        </div>
        <div class="col">
            <h5 class="bg-primary p-2 text-white">Widths</h5>
            <table class="table table-striped table-sm">
                <tr><th>UniqueIdent</th><th>Name</th></tr>
                @foreach (ShoeWidth s in ViewBag.Widths)
                {
                    <tr><td>@s.UniqueIdent</td><td>@s.WidthName</td></tr>
                }
            </table>
        </div>
    </div>
</div>

要测试重写数据模型约定的这两种技术,请使用dotnet run启动应用程序,运行并导航到 http://localhost:5000/manual 。在启动期间,Entity Framework Core 将使用属性和 Fluent API 语句来构建数据模型,从而将 action 方法所做的查询无缝地映射到数据库中,生成如图18-3所示的结果。

图18-3 重写数据模型

响应数据库更改

必须确保在对数据库进行更改时更新数据模型。没有自动更新过程,例如当使用第17章中描述的脚手架功能时。

如果没有正确更新数据模型,就会在 Entity Framework Core 和数据库之间造成不匹配。这种问题要到运行时才会显现出来,并可能导致仅针对特定操作或特定数据值才会出现的微妙问题。

确保您理解数据库更改将对应用程序产生的影响,并遵循一种测试机制,以尽量减少错误进入生产系统的可能性。


关系建模

虽然我已经设置了三个类来表示数据库中的数据,但每个类都是独立存在的,必须单独查询。定义类之间的关系意味着添加导航和外键属性,遵循用于代码先行开发模式的相同约定。清单18-12显示了对Shoes类的更改,添加导航和外键属性,该属性对数据库中 Shoes 和 Colors 表之间的一对多关系的一侧进行建模。

清单 18-12:Models/Manual 文件夹下的 Shoes.cs 文件,添加属性

namespace ExistingDb.Models.Manual
{
    public class Shoe
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        public long ColorId { get; set; }
        public Style Color { get; set; }
    }
}

在对现有数据库建模时,最重要的属性是映射到数据库表中用于存储依赖实体的外键列的属性,该属性是本例中的Shoe类。导航属性命名的约定是从外键属性中删除列名,以便存储在ColorId属性中的关系的导航属性为Color。注意,Color属性返回的类型是Style;Entity Framework Core 一致地应用于数据模型的更改,这意味着即使在定义关系时,用于将Style类指定为 Colors 表中数据表示形式的特性仍将继续。

为了完成这种关系,我向Style类添加了逆导航属性,如清单18-13所示。

清单 18-13:Models/Manual 文件夹下的 Style.cs 文件,完全关系

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Collections.Generic;

namespace ExistingDb.Models.Manual
{
    [Table("Colors")]
    public class Style
    {
        [Key]
        [Column("Id")]
        public long UniqueIdent { get; set; }
        [Column("Name")]
        public string StyleName { get; set; }
        public string MainColor { get; set; }
        public string HighlightColor { get; set; }
        public IEnumerable<Shoe> Shoes { get; set; }
    }
}

使用特性重写关系约定

清单18-12中定义的关系通过应用于模型的更改揭示了数据库的底层结构,结果是您跟随一个名为Color的属性来获得Style对象。

重写数据模型约定的一个缺点是,一旦开始,您就需要一直进行更改,包括确保导航和外键属性的名称与您所做的其他更改保持一致。在清单18-14中,我使用了属性来覆盖Shoe类中外键和导航属性的默认关系约定。

清单 18-14:Models/Manual 文件夹下的 Shoe.cs 文件,重写约定

using System.ComponentModel.DataAnnotations.Schema;

namespace ExistingDb.Models.Manual
{
    public class Shoe
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        [Column("ColorId")]
        public long StyleId { get; set; }
        [ForeignKey("StyleId")]
        public Style Style { get; set; }
    }
}

创建与数据模型其余部分一致的属性的关系需要两个特性。Column特性告诉 Entity Framework Core StyleId属性应当被映射至ColorId列,ForeignKey特性用于指定StyleId属性对于Style导航属性来说,是一个外键属性。总之,这些属性允许名称与数据模型的其余部分一致的属性表示关系,而不需要公开底层数据库结构的详细信息。

我添加到清单18-14中的Styles类中的属性不需要特性性,因为它遵循常规关系竞争和 Entity Framework Core,它将被识别为Shoe类的逆导航属性。但是,如果不能使用这种类型的属性的常规名称,那么可以使用InverseProperty特性来告诉 Entity Framework Core,属性与哪个类有关,如清单18-15所示。

清单 18-15:Models/Manual 文件夹下的 Style.cs 文件,标识逆导航属性

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Collections.Generic;

namespace ExistingDb.Models.Manual
{
    [Table("Colors")]
    public class Style
    {
        [Key]
        [Column("Id")]
        public long UniqueIdent { get; set; }
        [Column("Name")]
        public string StyleName { get; set; }
        public string MainColor { get; set; }
        public string HighlightColor { get; set; }

        [InverseProperty(nameof(Shoe.Style))]
        public IEnumerable<Shoe> Products { get; set; }
    }
}

InverseProperty特性的参数是关系中其他属性的名称,可以将其指定为字符串或使用nameof函数来避免键入。在清单中,InverseProperty特性允许我将逆属性的名称更改为Products

为了将来的快速参考,表18-6描述了本节中用于重关系约定的特性。(表18-3描述了Column特性。)

表 18-6:用于重写关系约定的特性

名称 描述
ForeignKey(property) 此特性用于将导航属性标识为外键属性
InverseProperty(name) 此特性用于在关系的另一端指定属性的名称

使用 Fluent API 重写关系约定

如果您更喜欢使用 Fluent API,那么可以使用Entity<T>方法来选择一个实体类,然后是Property方法,它允许您选择和配置单个属性。 为了显示如何使用 Fluent API 来描述关系,我在列表18-16中向Shoe类添加了外键和导航属性,以创建与ShoeWidth类的关系,该关系表示来自Fittings数据库表的数据。

清单 18-16:Models/Manual 文件夹下的 Shoe.cs 文件,定义属性

using System.ComponentModel.DataAnnotations.Schema;

namespace ExistingDb.Models.Manual
{
    public class Shoe
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        [Column("ColorId")]
        public long StyleId { get; set; }
        [ForeignKey("StyleId")]
        public Style Style { get; set; }
        public long WidthId { get; set; }
        public ShoeWidth Width { get; set; }
    }
}

要完全关系,我向ShoeWidth类添加了逆导航属性,如清单18-17所示。

清单 18-17:Models/Manual 文件夹下的 ShoeWidth.cs 文件,添加导航属性

using System.Collections.Generic;

namespace ExistingDb.Models.Manual
{
    public class ShoeWidth
    {
        public long UniqueIdent { get; set; }
        public string WidthName { get; set; }
        public IEnumerable<Shoe> Products { get; set; }
    }
}

这些属性不遵循关系约定,也没有用特性进行修饰,这意味着 Entity Framework Core 将无法确定其目的。为了描述这些属性在数据模型中的作用,我将清单18-18所示的语句添加到ManualContext类的OnModelCreating方法中。

清单 18-18:Models/Manual 文件夹下的 ManualContext.cs 文件,重写约定

using Microsoft.EntityFrameworkCore;

namespace ExistingDb.Models.Manual
{
    public class ManualContext : DbContext
    {
        public ManualContext(DbContextOptions<ManualContext> options)
            : base(options) { }
        public DbSet<Shoe> Shoes { get; set; }
        public DbSet<Style> ShoeStyles { get; set; }
        public DbSet<ShoeWidth> ShoeWidths { get; set; }
        protected override void OnModelCreating(ModelBuilder modelBuilder) {
            modelBuilder.Entity<ShoeWidth>().ToTable("Fittings");
            modelBuilder.Entity<ShoeWidth>().HasKey(t => t.UniqueIdent);
            modelBuilder.Entity<ShoeWidth>()
                .Property(t => t.UniqueIdent)
                .HasColumnName("Id");
            modelBuilder.Entity<ShoeWidth>()
                .Property(t => t.WidthName)
                .HasColumnName("Name");
            modelBuilder.Entity<Shoe>()
                .Property(s => s.WidthId).HasColumnName("FittingId");
            modelBuilder.Entity<Shoe>()
                .HasOne(s => s.Width).WithMany(w => w.Products)
                .HasForeignKey(s => s.WidthId).IsRequired(true);
        }
    }
}

需要两个配置语句来配置清单18-17和清单18-18中定义的属性,以表示 Shoes 和 Fittings 表之间的关系。第一条语句使用上一节中演示的相同方法,这些方法告诉 Entity Framework Core,应该从FittingId列读取Shoe.WidthId属性的值。第二个语句使用 Fluent API 提供专门用于描述关系的方法,如表18-7中所述。

表 18-7:使用EntityBuilder<T>方法描述关系

名称 描述
HasOne(property) 此方法用于开始描述所选实体类与另一类型的单个对象之间的关系。参数按名称或使用 lambda 表达式选择导航属性。
HasMany(property) 此方法用于开始描述所选实体类与其他类型的多个对象之间的关系。参数按名称或使用 lambda 表达式选择导航属性。

使用表18-8中描述的方法之一开始,使用表18-8中描述的方法之一来描述关系的另一端,该方法告知 Entity Framework Core 这是否是一对一或一对多关系。

表 18-18:使用 Fluent API 方法完全关系描述

名称 描述
WithMany(property) 该方法用于选择一对多关系中的逆导航属性。
WithOne(property) 该方法用于选择一对一关系中的逆导航属性。

选择了关系的两端的导航属性后,可以通过将调用链接到表18-9中描述的方法来配置关系。

表 18-9:Fluent API 关系配置方法

名称 描述
HasForeignKey(property) 此方法用于为关系选择外键属性。
IsRequired(required) 此方法用于指定关系为必要的还是可选的。

清单18-18中使用的方法组合告诉 Entity Framework Core,Shoe类是与ShoeWidth类的必要一对多关系中的依赖实体,而WidthId属性是外键属性。与将WidthId属性映射到数据库中的外键列的语句相组合,Entity Framework Core 拥有它需要了解关系的所有信息,以及如何将实体类映射到数据库表。

完全数据模型

为完全数据模型,我需要定义类以表示 SalesCampaigns 和 Categories 表,并描述它们之间的关系。我在 Models/Manual 文件夹下添加了一个名为 SalesCampaign.cs 的文件,并使用它定义清单18-19中的类。

清单 18-19:Models/Manual 文件夹下的 SalesCampaign.cs 文件的内容

using System;
using System.ComponentModel.DataAnnotations.Schema;

namespace ExistingDb.Models.Manual
{
    [Table("SalesCampaigns")]
    public class SalesCampaign
    {
        public long Id { get; set; }
        public string Slogan { get; set; }
        public int? MaxDiscount { get; set; }
        public DateTime? LaunchDate { get; set; }
        public long ShoeId { get; set; }
        public Shoe Shoe { get; set; }
    }
}

我使用Table特性指定将用于创建SalesCampaign对象的表,允许我在数据模型中保持类的名称一致。SalesCampaign类定义的属性直接映射到数据库表中的列,但Shoe属性除外,它是与Shoe类一对一关系的导航属性。

Categories表存在多对多关系,这意味着我需要定义实体和连接类。对于实体,我在 Models/Manual 文件夹下创建了一个名为 Category.cs 的文件,并使用它定义了清单18-20所示的类。

清单 18-20:Models/Manual 文件夹下的 Category.cs 文件的内容

using System.Collections.Generic;

namespace ExistingDb.Models.Manual
{
    public class Category
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public IEnumerable<ShoeCategoryJunction> Shoes { get; set; }
    }
}

要表现此关系所需的连接,我在 Models/Manual 文件夹下创建了一个名为 ShoeCategoryJunction.cs 的文件,并定义了清单18-21所示的类。

清单 18-21:Models/Manual 文件夹下的 ShoeCategoryJunction.cs 文件的内容

namespace ExistingDb.Models.Manual
{
    public class ShoeCategoryJunction
    {
        public long Id { get; set; }
        public long ShoeId { get; set; }
        public long CategoryId { get; set; }
        public Category Category { get; set; }
        public Shoe Shoe { get; set; }
    }
}

这个类定义了与数据库连接表中的列对应的属性,以及使用数据模型中类名的导航属性,这些类名代替了常规名称。类的名称与数据库中的连接表的名称相同。由于连接类符合所有约定,因此配置类或其关系不需要任何特性或 Fluent API 语句。

在清单18-22中,我为与清单18-21中的Category类的关系添加了SalesCampaign类和连接类所需的导航属性。

清单 18-22:Models/Manual 文件夹下的 Shoe.cs 文件,添加导航属性

using System.ComponentModel.DataAnnotations.Schema;
using System.Collections.Generic;

namespace ExistingDb.Models.Manual
{
    public class Shoe
    {
        public long Id { get; set; }
        public string Name { get; set; }
        public decimal Price { get; set; }
        [Column("ColorId")]
        public long StyleId { get; set; }
        [ForeignKey("StyleId")]
        public Style Style { get; set; }
        public long WidthId { get; set; }
        public ShoeWidth Width { get; set; }
        public SalesCampaign Campaign { get; set; }
        public IEnumerable<ShoeCategoryJunction> Categories { get; set; }
    }
}

为了提供对Category对象的方便访问,我向 context 类添加了一个DbSet<T>属性,如清单18-23所示。

清单 18-23:Models/Manual 文件夹下的 ManualContext.cs 文件,配置关系

using Microsoft.EntityFrameworkCore;

namespace ExistingDb.Models.Manual
{
    public class ManualContext : DbContext
    {
        public ManualContext(DbContextOptions<ManualContext> options)
            : base(options) { }
        public DbSet<Shoe> Shoes { get; set; }
        public DbSet<Style> ShoeStyles { get; set; }
        public DbSet<ShoeWidth> ShoeWidths { get; set; }
        public DbSet<Category> Categories { get; set; }
        protected override void OnModelCreating(ModelBuilder modelBuilder) {
            // ...此处省略...
        }
    }
}

使用手工创建的数据模型

现在数据模型已经完成,我可以在应用程序的 ASP.NET Core MVC 部分中使用数据,并让 Entity Framework Core 处理将其映射到没有遵循约定的数据库。在接下来的部分中,我将演示查询和更新数据的过程。

在手工创建的数据模型中查询数据

我在Manual控制器的Index action 中扩展了查询,将所有可用数据包括在内,并为每种数据类型添加了ViewBag属性,如清单18-24所示,以演示对模型中所有关系的查询。

清单 18-24:Controllers 文件夹下的 ManualController.cs 文件,查询附加数据

using ExistingDb.Models.Manual;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;

namespace ExistingDb.Controllers
{
    public class ManualController : Controller
    {
        private ManualContext context;
        public ManualController(ManualContext ctx) => context = ctx;
        public IActionResult Index()
        {
            ViewBag.Styles = context.ShoeStyles.Include(s => s.Products);
            ViewBag.Widths = context.ShoeWidths.Include(s => s.Products);
            ViewBag.Categories = context.Categories
                .Include(c => c.Shoes).ThenInclude(j => j.Shoe);
            return View(context.Shoes.Include(s => s.Style)
                .Include(s => s.Width).Include(s => s.Categories)
                    .ThenInclude(j => j.Category));
        }
    }
}

为向用户显示数据,我向 action 方法所使用的 Index 视图添加了清单18-25中的元素。

清单 18-25:Views/Manual 文件夹下的 Index.cshtml 文件,显示数据

@using ExistingDb.Models.Manual
@model IEnumerable<Shoe>
@{
    ViewData["Title"] = "Manual Data Model";
    Layout = "_Layout";
}

<div class="container-fluid">
    <h4 class="bg-primary p-3 text-white">Zoom Shoes</h4>
    <table class="table table-striped table-sm">
        <tr>
            <th>Id</th>
            <th>Name</th>
            <th>Price</th>
            <th>Styles</th>
            <th>Widths</th>
            <th>Categories</th>
            <th></th>
        </tr>
        @foreach (Shoe s in Model)
        {
            <tr>
                <td>@s.Id</td>
                <td>@s.Name</td>
                <td>$@s.Price.ToString("F2")</td>
                <td class="table-primary">@s.Width?.WidthName</td>
                <td class="table-secondary">@s.Style?.StyleName</td>
                <td class="table-success">
                    @string.Join(", ", s.Categories.Select(c => c.Category.Name))
                </td>
                <td class="text-center">
                    <a asp-action="Edit" asp-route-id="@s.Id"
                       class="btn btn-sm btn-primary">Edit</a>
                </td>
            </tr>
        }
    </table>
    <div class="row">
        <div class="col">
            <h5 class="bg-primary p-2 text-white">Styles</h5>
            <table class="table table-striped table-sm">
                <tr><th>UniqueIdent</th><th>Name</th><th>Products</th></tr>
                @foreach (Style s in ViewBag.Styles)
                {
                    <tr>
                        <td>@s.UniqueIdent</td>
                        <td>@s.StyleName</td>
                        <td class="table-secondary">
                            @String.Join(", ", s.Products.Select(p => p.Name))
                        </td>
                    </tr>
                }
            </table>
        </div>
        <div class="col">
            <h5 class="bg-primary p-2 text-white">Widths</h5>
            <table class="table table-striped table-sm">
                <tr><th>UniqueIdent</th><th>Name</th><td>Products</td></tr>
                @foreach (ShoeWidth s in ViewBag.Widths)
                {
                    <tr>
                        <td>@s.UniqueIdent</td>
                        <td>@s.WidthName</td>
                        <td class="table-primary">
                            @String.Join(", ", s.Products.Select(p => p.Name))
                        </td>
                    </tr>
                }
            </table>
        </div>
        <div class="col">
            <h5 class="bg-primary p-2 text-white">Categories</h5>
            <table class="table table-striped table-sm">
                <tr><th>Id</th><th>Name</th><th>Products</th></tr>
                @foreach (Category c in ViewBag.Categories)
                {
                    <tr>
                        <td>@c.Id</td>
                        <td>@c.Name</td>
                        <td class="table-success">
                            @String.Join(", ", c.Shoes.Select(j => j.Shoe.Name))
                        </td>
                    </tr>
                }
            </table>
        </div>
    </div>
</div>

启动应用程序并导航至 http://localhost:5000/manual 以查看新内容,如图18-4所示。

图18-4 使用一个手工创建数据库

清单18-25中的添加产生了一系列表,这些表显示了数据库中的数据,表明使用手动创建的数据模型的工作方式与作为迁移基础或脚手架的工作方式是一样的。Entity Framework Core 自动处理与约定的任何偏差,您可以通过检查应用程序的输出来看到这些偏差。下面是用于获取图中显示的数据的查询之一;您可以看到如何将与约定不同的类和属性的名称转换为数据库中的表和列:

...
SELECT [c.Shoes].[Id], [c.Shoes].[CategoryId], [c.Shoes].[ShoeId], [s.Shoe].[Id],
    [s.Shoe].[Name], [s.Shoe].[Price], [s.Shoe].[ColorId], [s.Shoe].[FittingId]
FROM [ShoeCategoryJunction] AS [c.Shoes]
INNER JOIN [Shoes] AS [s.Shoe] ON [c.Shoes].[ShoeId] = [s.Shoe].[Id]
INNER JOIN (
    SELECT [c0].[Id]
    FROM [Categories] AS [c0]
) AS [t] ON [c.Shoes].[CategoryId] = [t].[Id]
ORDER BY [t].[Id]
...

警告:创建手动数据模型会在应用程序的 ASP.NET Core MVC 部分获得更自然的开发体验,但只有当您准确地对数据库建模时,它才能工作。Entity Framework Core 并不验证您创建的模型是否准确地描述了数据库,只有当应用程序试图查询或更新数据库时,任何问题才会变得明显。

在手工创建的数据模型中更新数据

我在清单18-25中的视图中添加了一个 a 元素,它的目标是一个称为编辑的操作。在本节中,我将实现操作方法,以强调使用手动创建的数据库与使用脚手架或用于创建迁移的数据库是完全相同的。在清单18-26中,我向控制器中添加了一些方法,这些方法将允许用户启动编辑过程并更新现有的Shoe对象。

清单 18-26:Controllers 文件夹下的 ManualController.cs 文件,添加 action

using ExistingDb.Models.Manual;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using System.Linq;
using System.Collections.Generic;

namespace ExistingDb.Controllers
{
    public class ManualController : Controller
    {
        private ManualContext context;
        public ManualController(ManualContext ctx) => context = ctx;

        public IActionResult Index()
        {
            ViewBag.Styles = context.ShoeStyles.Include(s => s.Products);
            ViewBag.Widths = context.ShoeWidths.Include(s => s.Products);
            ViewBag.Categories = context.Categories
                .Include(c => c.Shoes).ThenInclude(j => j.Shoe);
            return View(context.Shoes.Include(s => s.Style)
                .Include(s => s.Width).Include(s => s.Categories)
                    .ThenInclude(j => j.Category));
        }

        public IActionResult Edit(long id)
        {
            ViewBag.Styles = context.ShoeStyles;
            ViewBag.Widths = context.ShoeWidths;
            ViewBag.Categories = context.Categories;
            return View(context.Shoes.Include(s => s.Style)
                .Include(s => s.Campaign)
                .Include(s => s.Width).Include(s => s.Categories)
                .ThenInclude(j => j.Category).First(s => s.Id == id));
        }
        
        [HttpPost]
        public IActionResult Update(Shoe shoe, long[] newCategoryIds,
        ShoeCategoryJunction[] oldJunctions)
        {
            IEnumerable<ShoeCategoryJunction> unchangedJunctions
                = oldJunctions.Where(j => newCategoryIds.Contains(j.CategoryId));
            context.Set<ShoeCategoryJunction>()
                .RemoveRange(oldJunctions.Except(unchangedJunctions));
            shoe.Categories = newCategoryIds.Except(unchangedJunctions
                .Select(j => j.CategoryId))
                .Select(id => new ShoeCategoryJunction
                {
                    ShoeId = shoe.Id,
                    CategoryId = id
                }).ToList();
            context.Shoes.Update(shoe);
            context.SaveChanges();
            return RedirectToAction(nameof(Index));
        }
    }
}

Edit action 查询数据库中的单个对象及其所有关联数据,以便显示当前的详细信息以进行编辑。ViewBag用于将附加数据传递给视图,以便向用户显示可用于更改关联数据的选项范围。

Update方法接收一个Shoe对象,该对象将由 ASP.NET Core MVC 模型绑定器从 HTTP 表单数据创建,并用于更新数据库。Update方法还接收一个数组,该数组包含用户希望与Shoe关联的Category对象的主键值,以及连接对象的数组,我将确保连接对象包含在 HTTP 请求数据中,这样我就不必查询数据库,只为了确定必须删除哪些连接对象。

在第16章中,我向您展示了如何从关系中的对象单独更新多对多的关系,这是通过查询关系一端的对象及其关联数据来实现的,这为 Entity Framework Core 提供了足够的信息来更新连接表。

这一技术在本章行不通,因为我想同时更新Shoe对象和多对多的关系,如果我查询数据库中的Shoe,那么 Entity Framework Core 更改跟踪功能在使用由 MVC 模型绑定器创建的ShoeUpdate方法时会抛出异常。

要执行更新,请通过将选定类别与最初关联的类别进行比较,并删除通过将对象传递到RemoveRange方法而不需要的那些类别,来删除任何过期的关系,例如:

...
IEnumerable<ShoeCategoryJunction> unchangedJunctions
    = oldJunctions.Where(j => newCategoryIds.Contains(j.CategoryId));

context.Set<ShoeCategoryJunction>()
    .RemoveRange(oldJunctions.Except(unchangedJunctions));
...

我没有为连接数据定义 context 属性,所以我通过使用Set<T>方法获得DbSet<T>对象来执行删除操作。为了避免创建重复的关系,我将原始连接对象集与用户选择的类别进行比较,确保只创建新对象以填补空白,如下所示:

...
shoe.Categories = newCategoryIds.Except(unchangedJunctions.Select(j => j.CategoryId))
    .Select(id => new ShoeCategoryJunction {
        ShoeId = shoe.Id, CategoryId = id
    }).ToList();
...

我删除不必要的关系并创建新的关系,同时小心地保留数据库中未更改的关系。通过这种方式,我能够使用 ASP.NET Core MVC 模型绑定器创建的对象来执行更新。

创建分部视图

要完成该示例,我需要为每个对象创建一个分部视图,该视图将允许用户执行编辑。我首先在 Views/Manual 文件夹中添加一个名为 EditShoe.cshtml 的文件,然后添加清单18-27所示的内容。

清单 18-27:Views/Manual 文件夹下的 EditShoe.cshtml 文件的内容

@using ExistingDb.Models.Manual
@model Shoe

<input type="hidden" asp-for="Id" />
<h4>Product Details</h4>
<div class="p-1 m-1">
    <div class="form-row">
        <div class="form-group col">
            <label asp-for="Name" class="form-control-label"></label>
            <input asp-for="Name" class="form-control" />
        </div>
        <div class="form-group col">
            <label asp-for="Price" class="form-control-label"></label>
            <input asp-for="Price" class="form-control" />
        </div>
    </div>
</div>

每个分部视图将仅包含执行呈现给用户的数据的一个方面所需的元素,并且在这种情况下,存在允许编辑的Shoe对象的NamePrice属性的两个input元素。接下来,我在 Views/Manual 文件夹中添加了一个名为 EditStyle.cshtml 的文件,并添加了清单18-28中所示的内容。

清单 18-28:Views/Manual 文件夹下的 EditStyle.cshtml 文件的内容

@using ExistingDb.Models.Manual
@model Shoe

<label><strong>Style:</strong></label>
<select asp-for="StyleId" class="form-control">
    @foreach (Style s in ViewBag.Styles)
    {
        if (s.UniqueIdent == Model.StyleId)
        {
            <option value="@s.UniqueIdent" selected>@s.StyleName</option>
        }
        else
        {
            <option value="@s.UniqueIdent">@s.StyleName</option>
        }
    }
</select>

此分部视图显示一个select元素,用于选择与正在编辑的Shoe相关的Style对象。我对ShoeWidth对象采取了类似的方法,在 Views/Manual 文件夹中创建了一个名为 EditWidth.cshtml 的文件,内容如清单18-29所示。

清单 18-29:Views/Manual 文件夹下的 EditWidth.cshtml 文件的内容

@using ExistingDb.Models.Manual
@model Shoe

<label>Width:</label>
<select asp-for="WidthId" class="form-control">
    @foreach (ShoeWidth w in ViewBag.Widths)
    {
        if (w.UniqueIdent == Model.WidthId)
        {
            <option value="@w.UniqueIdent" selected>@w.WidthName</option>
        }
        else
        {
            <option value="@w.UniqueIdent">@w.WidthName</option>
        }
    }
</select>

SalesCampaign类之间存在一对一的关系,因此我选择向用户提供input元素,以更改属性值,方法是在 Views/Manual 文件夹中创建一个名为 EditCampaign.cshtml 的文件,并添加清单18-30中所示的内容。

清单 18-30:Views/Manual 文件夹下的 EditCampaign.cshtml 文件的内容

@using ExistingDb.Models.Manual
@model Shoe

<div class="form-row">
    <input type="hidden" asp-for="Campaign.Id" />
    <label><strong>Sales Campaign:</strong></label>
</div>
<div class="form-row">
    <div class="form-group col">
        <label asp-for="Campaign.Slogan" class="form-control-label"></label>
        <input asp-for="Campaign.Slogan" class="form-control" />
    </div>
</div>
<div class="form-row">
    <div class="form-group col">
        <label class="form-control-label">Max Discount:</label>
        <input asp-for="Campaign.MaxDiscount" class="form-control" />
    </div>
    <div class="form-group col">
        <label class="form-control-label">Launch Date:</label>
        <input type="date" asp-for="Campaign.LaunchDate" class="form-control" />
    </div>
</div>

最后一个分部视图将允许用户选择要编辑的Shoe对象与哪些Category对象相关。这是一个更复杂的视图,因为我包含了 ASP.NET Core MVC 模型绑定器将使用的隐藏元素,以创建用于避免必须查询数据库的连接对象,以确定Update action 方法中的当前关系集。我在 Views/Manual 文件夹中添加了一个名为 EditCategory.cshtml 的视图文件,并添加了清单18-31中所示的内容。

清单 18-31:Views/Manual 文件夹下的 EditCategory.cshtml 文件的内容

@using ExistingDb.Models.Manual
@model Shoe

@{ int index = 0; }

@foreach (var junc in Model.Categories)
{
    <input type="hidden" name="oldJunctions[@index].Id" value="@junc.Id" />
    <input type="hidden" name="oldJunctions[@index].CategoryId"
           value="@junc.CategoryId" />
    index++;
}
@foreach (Category c in ViewBag.Categories)
{
    <div class="form-group col">
        <label class="form-check-label">
            @if (c.Shoes?.Any(s => s.ShoeId == Model.Id) == true)
            {
                <input type="checkbox" name="newCategoryIds" value="@c.Id"
                       checked class="form-check-input" />
            }
            else
            {
                <input type="checkbox" name="newCategoryIds" value="@c.Id"
                       class="form-check-input" />
            }
            @c.Name
        </label>
    </div>
}

使用index计数器的尴尬foreach循环用于生成将由 ASP.NET Core MVC 模型绑定器正确处理的数据。其他元素生成一系列标签和复选框,这些标签和复选框用于选择鞋子的类别。

创建编辑器视图

剩下的就是创建一个包含分部视图的视图,向用户提供一个编辑器。我在 Views/Manual 文件夹中添加了一个名为 Edit.cshtml 的文件,内容如清单18-32所示。

清单 18-32:Views/Manual 文件夹下的 Edit.cshtml 文件的内容

@using ExistingDb.Models.Manual
@model Shoe
@{
    ViewData["Title"] = "Manual Data Model";
    Layout = "_Layout";
}

<form asp-action="Update" method="post">
    @await Html.PartialAsync("EditShoe", Model)
    <div class="p-1 m-1">
        <div class="form-row">
            <div class="form-group col">@await Html.PartialAsync("EditStyle", Model)</div>
            <div class="form-group col">@await Html.PartialAsync("EditWidth", Model)</div>
        </div>
         @await Html.PartialAsync("EditCampaign", Model)
        <div class="form-row">
            <label><strong>Categories:</strong></label>
        </div>
        <div class="form-row">@await Html.PartialAsync("EditCategory", Model)</div>
    </div>
    <div class="text-center m-1">
        <button type="submit" class="btn btn-primary">Save</button>
        <a asp-action="Index" class="btn btn-secondary">Cancel</a>
    </div>
</form>

要检查在手动创建的数据模型中更新数据的代码和视图,请启动应用程序,导航到 http://localhost:5000/manual,然后单击【All Terrain Monster】产品的编辑按钮。将【Width】选项更改为“Big Foot”,然后取消选中【Trail】类别。单击【Save】按钮,您将看到在概述中反映的更改,如图18-5所示。

图18-5 在手工创建数据模型中编辑数据

总结

本章,我演示了手工建模数据库的过程。这是一个需要小心和关注的过程,但是与第17章中描述的脚手架过程相比,它可以生成一个更易于在应用程序的 ASP.NET Core MVC 部分中使用的数据模型。在第 3 部分中,我将描述高级 Entity Framework Core 特性。

;

© 2018 - IOT小分队文章发布系统 v0.3